身為冒險者的你,如果不小心闖入遊戲中的森林火海因而引火上身,那麼就會持續受到燒傷的傷害。不過另人好奇的是,即使受到四面八方無數的火焰轟擊,被燒傷的人物仍然只固定每兩秒掉5滴血,這樣的傷害頻率在遊戲中是如何管理的呢?
類似火焰這種傷害頻率管理的問題,還能在其他的情境中看到,比如DOTA遊戲裏的回血區、聊天室的發言頻率、甚至判定時間較久的一般攻擊招式也都需要這種管理機制。
我們先看看回血區這種類型的問題核心。回血區的功能就是讓站在這塊區域的玩家,以一定的速率回復血量。最理想的作法,就是定義一個回血率,假設是每毫秒0.01滴血,那麼在每一幀更新的時候,根據自上一幀到這一幀經過的時間dt(單位為毫秒),為每位玩家回復 的血量。如果能這樣處理就不需要什麼管理系統了。
不幸的是,上面說的方式有幾個缺點,第一是這樣連續的回血方式,很難以跳數字的方式讓玩家感受回血的速率;第二是在網路連線的遊戲,這樣更新的頻率會傷害遊戲同步的效率和準確率;第三是這樣的算法,因為每一幀回復的血量很低,所以玩家的血量必須使用浮點數來定義,增添了遊戲中其他地方計算或顯示上的複雜度。
因為存在著以上種種原因,有些遊戲甘脆就將回血率定在每兩秒回復20滴血,這樣感覺跟每毫秒0.01的回血速率一樣,好處是如此一來回血區只需要每兩秒去執行一次加血的工作,大大地降低連線遊戲需要同步的頻率。
不過即使改成低頻更新的方式仍然會造成一些新的問題,比如所有在回血區的人會同時跳出回血的數字,這樣有可能會造成玩家不易讀字的困擾,也可能讓遊戲看起來很死硬。另外,因為回血發生的時機是固定的,所以有可能玩家走進回血區後不會馬上感受到回血區的效果,而需要等候一段時間才看得到回血區發生作用,這非常可能成為一個不好的遊戲體驗。
基於以上種種原因,我們發現一個能幫助我們管理遊戲裏各個機制發生的頻率的系統,是勢在必行的。
冷卻系統是最常被用來解決這個事件頻率問題的方案。以回血區為例,我們每幀都讓回血區去檢查有沒有人站在上面,發現有人就給他回血,然後在回血事件發生時,將這個人的觸發條件加上冷卻時間就行了,這樣在下一幀檢查的時候,雖然發現了同一個人站在上面,但因為這個人目前在冷卻期,所以略過不加血。我們將這個系統稱為一台冷卻機。
但是另一個問題又來了,這個冷卻機要放在哪裏好呢?以回血區來說,很明顯要放在回血區的物件裏,然後針對每個玩家儲存一個冷卻時間,之後在每一幀更新的時候檢查站在區域內的玩家,如果玩家已不在冷卻時間內就可以幫他加血,再順便給他一個新的冷卻時間,這樣就行了。
但是如果是火龍噴火,或是森林大火的情況,我們無法讓每一顆小火球小火焰各自帶上一台冷卻機來解決持續燒傷的問題,因為燒傷的來源雖然是小火焰,但是燒傷的頻率計算應該是放在人物身上,但是將冷卻系統綁在人物身上又不是很雅觀,畢竟燒傷冷卻的各項參數應該是放在小火焰裏,和人物本身應該是無關的。
這種時候我們會需要一個專門管理燒傷的中央冷卻系統來進行燒傷的頻率管理,每個小火焰在進行傷害的時候,就向中央冷卻系統問一問,只有脫離冷卻時間的玩家要給予傷害並更新冷卻時間。
這邊我儘量寫一段示意碼來演示這個中央冷卻系統的概念給同學們看。
首先定義一下遊戲中會出現的玩家和火焰類別。
/** 玩家類別 */
class Player {
/** 有ID */
id: string;
/** 有血量 */
hp: number;
/** 有位置 */
position: Point;
}
/** 火焰類別 */
class Flame {
/** 有位置 */
position: Point;
/** 有碰撞半徑 */
radius: number;
/** 有傷害力 */
damage: number;
/** 有傷害冷卻時間 */
cooldownDuration: number;
}
接著定義一下遊戲本身。
class Game {
/** 所有玩家的陣列 */
players: Player[] = [];
/** 所有火焰的陣列 */
flames: Flame[] = [];
/** 還要有一台中央冷卻系統(類別寫在下面) */
cooldownSystem = new CentralCooldownSystem();
}
然後來設計我們的冷卻機。
class CooldownManager {
/** 一個用來儲存所有人物冷卻時間的Map
* 這裏用的型別比較怪,沒看過的同學可能會嚇一跳!
* 下面的註解會再詳加說明。
*/
endTime: {[key: string]: number} = {};
/** 檢查某個人物id的冷卻時間是否已結束 */
isCooldownEnded(id: string): boolean {
// 取得id對應的冷卻時間
// || 0 是指在 this.endTime[id]的值不存在的時候,選用0這個數字
let end = this.endTime[id] || 0;
// Date.now() 是系統工具,會給我們目前的系統時間(單位為毫秒)
return Date.now() > end;
}
/** 更新某個人物id的冷卻時間 */
updateCooldownEnd(id: string, cooldown: number) {
let currentEndtime = this.endTime[id] || 0;
/** 將冷卻結束時間更新至 目前時間+冷卻時間
* 不過更新後不能比目前已經需要冷卻的時間更快結束
*/
this.endTime[id] = Math.max(
Date.now() + cooldown,
currentEndtime
);
}
}
接著來寫中央冷卻系統。
class CentralCooldownSystem {
/** 用來儲存所有冷卻機的Map */
managers: {[key: string]: CooldownManager} = {};
/** 依功能取得對應的冷卻機 */
getManager(key: string): CooldownManager {
// 先在this.managers裏找
let manager = this.managers[key];
// 如果manager不存在就新增一個
if(!manager) {
manager = new CooldownManager();
this.managers[key] = manager;
}
return manager;
}
/** 取得燒傷的冷卻機 */
getBurningManager(): CooldownManager {
return this.getManager('burning');
}
}
這樣我們的前置作業就完成後,最後來寫火焰給予燒傷的程式碼。
function updateBurningDamages(game: Game) {
// 跑所有火焰的迴圈
for(let flame of game.flames) {
// 跑所有玩家的迴圈
for(let player of game.players) {
// 檢查玩家是否在火焰半徑內
if(Point.distance(
flame.position,
player.position
) < flame.radius
) {
// 檢查玩家冷卻是否已結束
if(game.cooldownSystem
.getBurningManager()
.isCooldownEnded(player.id)
) {
// 記錄一下受傷前的血量
let originHP = player.hp;
// 給予玩家傷害
player.hp -= flame.damage;
// 重新設定玩家的冷卻時間
game.cooldownSystem
.getBurningManager()
.updateCooldownEnd(
player.id,
flame.cooldownDuration
);
// 列印一下這個燒傷事件
console.log(`玩家${player.id}`+
`受到燒傷hp:`+
`${originHP}=>${player.hp}`);
}
}
}
}
}
這樣就完成了整個流程。
JavaScript/TypeScript的物件有點像是一個盒中盒,盒子打開不只可以看到放在裏面的物件,還可能有許多小盒子在裏面,而小盒子打開也同樣看得到裏面裝的物件和其他小盒子。
/** 下面這個叫 girl 的物件,就屬於通用物件型別
* 裏面可以放一個名字的字串、
* 一個年齡的數字、
* 還有另一個叫equipment的盒子(另一個通用物件)
*/
let girl = {
name: '廣末o子',
age: 17,
equipment: {
hat: 'ZALORA',
dress: 'UNIQLO',
shoes: 'Stuart Weitzman',
},
};
// 我們可以用以下的方式來取得girl裏的屬性
console.log("我的名字是" + girl.name);
console.log("我身上這件是" + girl.equipment.dress);
// 我們也能用類似的方法來改變girl裏的屬性
girl.age += 1;
console.log("今天是我" + girl.age + "歲的生日");
如果我們在TypeScript裏想要限制一個通用物件裏能放的型別,就可以這樣宣告,
let endTime: {[key: string]: number};
/** 以下是endTime符合型別的正確物件內容 */
endTime = {
playerA: 100,
playerB: 200,
playerC: 300,
};
/** 以下的endTime會讓TypeScript編譯器出現錯誤
* 因為endTime裏面的值沒有遵守一定要是number的定義
*/
endTime = {
playerA: 'To the end of time',
playerB: 200,
playerC: {
name: 'C',
id: 1111,
},
};
強制變數的型別是TypeScript幫助我們寫出好程式的幫手,希望同學們慢慢習慣幫變數們加上適當的型別。準備偷懶的時候,請想想Debug的痛苦,就不會再怕麻煩了。
大家可能注意到了,在列印燒傷事件時,小哈用了反引號(`)來定義字串。字串可以使用單引號(')、雙引號(")或反引號(`)來定義,比如下面這三行程式碼都是ok的。
let str1 = '這是第一個字串';
let str2 = "這是第二個字串";
let str3 = `這是第二個字串`;
不過使用反引號,比起其他兩種引號多了一些好處。第一個好處是可以直接在字串中換行,不需要使用換行碼。
// 以下使用換行碼(\n)來插入換行的記號。
let str1 = "這是第一行\n然後是第二行\n最後是第三行";
// 以下使用反引號,可以直接換行,達成相同的效果。
let str2 = `這是第一行
這是第二行
這是第三行`;
反引號的第二個好處,是可以在字串中插入變數,只要使用${ }把變數包在裏面,就可以把變數安插在字串中。
// 假設我們有三個變數
let name = "小哈片刻";
let verb = "愛";
let obj = "玩遊戲";
// 我們可以這樣組成一個字串
let sentence1 = "我發現: " + name + " " + verb + " " + obj + "。";
// 也可以這樣寫
let sentence2 = `我發現: ${name} ${verb} ${obj}。`;